Let a tripped circuit breaker keep retrying instead of latching open - #70
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the per-registry circuit breaker behavior in fetch so that a breaker does not permanently “latch open” after long outages, and so “breaker open” errors are consistently reported to callers.
Changes:
- Configure the custom exponential backoff used by
CircuitBreakerFetcherto retry indefinitely (MaxElapsedTime = 0) and to share the same clock as the breaker. - Remove redundant
Ready()pre-checks soCall()alone governs probe admission, avoiding accidental probe “spending”. - Normalize open-breaker errors via
breakerError()so callers consistently seeErrUpstreamDown.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| README.md | Updates circuit breaker documentation to reflect rolling-window trip behavior and indefinite probing/retry semantics. |
| go.mod | Promotes github.com/facebookgo/clock to a direct dependency (used for breaker/backoff time source). |
| fetch/circuit_breaker.go | Implements indefinite backoff, unified clock usage, removes redundant Ready() checks, and maps breaker-open errors to ErrUpstreamDown. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Retry forever, which is what the breaker library itself defaults to. | ||
| // NewExponentialBackOff instead defaults MaxElapsedTime to 15 minutes, after | ||
| // which NextBackOff returns backoff.Stop and the breaker never half-opens | ||
| // again. Only a success resets the backoff, and the breaker no longer lets | ||
| // one through, so an outage lasting longer than MaxElapsedTime leaves the | ||
| // breaker open for the life of the process even after the registry recovers. | ||
| expBackoff.MaxElapsedTime = 0 |
andrew
left a comment
There was a problem hiding this comment.
The fix looks right: MaxElapsedTime = 0 stops the backoff returning Stop, dropping the Ready() pre-check stops it consuming the half-open probe before Call() runs, and breakerError makes both refusal paths surface ErrUpstreamDown.
One thing before this can go in: the two tests the description covers, TestCircuitBreakerRecoversAfterProlongedOutage and TestCircuitBreakerProbesOncePerBackoffInterval, aren't in the diff. No _test.go file is changed, and without them the new clock field on CircuitBreakerFetcher is a test hook nothing uses. Looks like they exist locally (the description quotes a specific failure line) but didn't make it into the commit. Please push them.
These were written alongside the fix but never made it into the commit: the file was untracked when the change was applied, so committing with -a skipped it. The PR therefore described coverage that was not in the diff, and left the clock field on CircuitBreakerFetcher as a test hook with no test using it. TestCircuitBreakerRecoversAfterProlongedOutage trips a breaker against a 503 server, advances a mock clock through an hour of failing probes, then brings the server back and asserts the next fetch succeeds and the breaker reports closed. It fails without MaxElapsedTime = 0, with the production symptom: "circuit breaker open for registry ...". TestCircuitBreakerProbesOncePerBackoffInterval asserts that an open breaker lets exactly one request per backoff interval reach the registry, refuses a second call in the same interval without contacting it, and wraps ErrUpstreamDown on both paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
missing test file has been pushed: sorry for that |
fix for the problem mentioned in the Notes section of git-pkgs/proxy#275
Problem
CircuitBreakerFetcher.getBreakerbuilds its ownbackoff.ExponentialBackOffand sets
InitialInterval,MaxIntervalandMultiplier, but leavesMaxElapsedTimeat cenk/backoff'sDefaultMaxElapsedTimeof 15 minutes.Once the elapsed time passes that,
NextBackOff()returnsbackoff.Stop, andrubyist/circuitbreaker's
state()only half-opensif cb.nextBackOff != backoff.Stop && since > cb.nextBackOff. The breaker therefore stops admittingprobes entirely. The one thing that would clear it is
Success(), the solecaller of
BackOff.Reset()— and a success is unreachable while no call getsthrough. So any outage lasting longer than 15 minutes leaves that host's breaker
open for the life of the process, long after the registry has recovered. Only a
restart clears it.
The breaker library itself does not have this problem:
NewBreakerWithOptionssets
MaxElapsedTimetodefaultBackoffMaxElapsedTime, which is 0, on thebackoff it constructs when no
BackOffoption is given. Supplying a custombackoff is what silently opts into the 15 minute cut-off.
This was found in production. A proxy built on this package served npm metadata
normally while every uncached tarball returned 502 in about 0.2s with
circuit breaker open for registry [registry.npmjs.org](http://registry.npmjs.org/), for hours afterregistry.npmjs.org was healthy again. Metadata does not go through the fetcher,
so only artifact downloads for that one host were affected, which made it look
like an npm-specific outage rather than latched local state.
A second defect made recovery slower and noisier than intended. Each fetch method
called
breaker.Ready()as a pre-check and thenbreaker.Call(), which checksthe breaker again. A
Ready()that observes half-open advances the backoff andclears the half-open flag, so the check inside
Call()re-tested against thealready-advanced interval and usually lost — spending the probe the call was
about to make. Measured against a dead upstream over 20 one-minute steps, only
3 requests actually reached it. Those refusals also returned the library's bare
circuit.ErrBreakerOpen, which does not wrapErrUpstreamDown, so callersbranching on
errors.Is(err, ErrUpstreamDown)— includingfetch/fetcher.go— did not recognise them.Change
expBackoff.MaxElapsedTime = 0ingetBreaker, matching the library's owndefault, so a tripped breaker keeps admitting one probe per backoff interval
for as long as the registry stays down and closes as soon as one succeeds.
Backoff growth is unchanged: 30s initial, doubling, capped at 5 minutes.
Ready()pre-check fromFetchWithHeaders,FetchObservedWithHeadersandHead. The same measurement now shows 6 probesover the same 20 steps, at the intervals the backoff actually specifies.
breakerError, which mapscircuit.ErrBreakerOpenonto the wrappedErrUpstreamDownand passes fetch errors through untouched, so every refusalto contact a registry reports the same way to callers.
expBackoff.Clockis now set to the same clock the breaker uses. Previouslythe breaker read
circuit.Options.Clockwhile its backoff readbackoff.SystemClock, so the two measured time from independent sources.clockfield onCircuitBreakerFetchersupplies that clock,nil meaning
clock.New(). It exists so the regression above can be tested:reproducing it requires pushing the backoff's elapsed time past 15 minutes,
which is not something a test should wait for, and both clocks have to advance
together for the reproduction to be faithful. It does not reach the breaker's
failure-count window, which the library keeps on the system clock. No public
API changes.
[github.com/facebookgo/clock](http://github.com/facebookgo/clock%60) moves from indirect to direct ingo.mod. Itwas already in the module graph as a dependency of rubyist/circuitbreaker;
go.sumis unchanged.Testing
TestCircuitBreakerRecoversAfterProlongedOutagetrips a breaker against aserver returning 503, then advances a mock clock through an hour of failing
probes in 10 minute steps — each step longer than the 5 minute maximum interval,
so every step admits exactly one probe and the sequence is deterministic. The
server then recovers and the test asserts the next fetch succeeds and
GetBreakerStatereportsclosed.Without the one-line backoff fix it fails with the production symptom:
TestCircuitBreakerProbesOncePerBackoffIntervalcovers the second defect: aftereach interval elapses, exactly one request reaches the registry, a second call
in the same interval reaches it zero times, and both errors wrap
ErrUpstreamDown.go build ./...,go vet ./...andgo test ./...pass; the breaker tests alsopass under
-race;golangci-lint run ./fetch/...reports 0 issues.Docs
The README circuit breaker section now describes the trip condition accurately —
5 failures inside the breaker's rolling 10 second failure window, which is what
ThresholdTripFuncmeasures, rather than 5 consecutive failures — and statesthat one request per backoff interval is let through as a probe while the rest
fail with
ErrUpstreamDownwithout contacting the registry, and that retriesnever give up, so a breaker recovers however long the registry was down. The
matching comment in
getBreakeris corrected the same way.Downstream
Consumers pick this up on their next dependency bump. Until then, a latched
breaker still needs a process restart to clear.